Skip to content

feat(analytics): World ID unique-verifications data layer (1/3) - #2191

Open
soamdesai-tfh wants to merge 19 commits into
mainfrom
feat/world-id-analytics-data
Open

feat(analytics): World ID unique-verifications data layer (1/3)#2191
soamdesai-tfh wants to merge 19 commits into
mainfrom
feat/world-id-analytics-data

Conversation

@soamdesai-tfh

@soamdesai-tfh soamdesai-tfh commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

First of three stacked PRs: single-metric World ID analytics (Unique Verifications across legacy + v4 actions). Data layer.

  • Rollup tables action_legacy_stats_daily / action_v4_stats_daily + read fn world_id_analytics_app_daily()
  • rollup_world_id_analytics(from_date, to_date) — stateless, idempotent delete+recount of a UTC date window; no dates = trailing ~25h; upper bound clamped to now − 5 min; blocking advisory lock; parent-action pre-locks so a concurrent action deletion waits briefly instead of deadlocking
  • Route /_rollup-world-id-analytics: empty body = cron mode (fail-closed behind WORLD_ID_ANALYTICS_ROLLUP_ENABLED, Hasura cron */15); {from_date, to_date, chunk_days?} = operator backfill/repair (secret-gated, ≤92 days/call, 10-day chunks, response lists failed_ranges to re-POST)
  • One ops file: create-nullifier-created-at-index.sql (CONCURRENTLY + validity gates)

Tests: fresh-stack 38/38 (window semantics, both-direction raw/rollup parity, index builds under live writes, real Hasura webhook delivery, end-to-end release runbook), route unit 13/13.

Deployment

Step 0 — pre-flight. HASURA_GRAPHQL_STRINGIFY_NUMERIC_TYPES=true in prod Hasura; WORLD_ID_ANALYTICS_ROLLUP_ENABLED absent or false in developer-portal-deployment.

Step 1 — build the indexes before merging this PR. From this PR branch, run the operation directly with psql (not through Hasura migrations or inside a transaction):

psql "$PROD_DATABASE_URL" -X -v ON_ERROR_STOP=1 \
  -f hasura/operations/world-id-analytics/create-nullifier-created-at-index.sql

Both source tables already exist on main. Wait for both concurrent index builds to finish successfully before merging.

Step 2 — merge this PR. The cron registers, but every tick returns disabled without touching the database, and nothing reads the new tables yet.

Step 3 — backfill all history. nullifier was created 2023-02-16, so 2023-02-01 provably predates all data;

start=2023-02-01
today=$(date -u +%Y-%m-%d)
while [[ "$start" < "$today" ]]; do
  end=$(date -d "$start +91 days" +%Y-%m-%d 2>/dev/null || date -j -v+91d -f %Y-%m-%d "$start" +%Y-%m-%d)
  echo "$start .. $end"
  curl -s -X POST "$PORTAL_URL/api/_rollup-world-id-analytics" \
    -H "Authorization: $INTERNAL_ENDPOINTS_SECRET" \
    -H "content-type: application/json" \
    -d "{\"from_date\":\"$start\",\"to_date\":\"$end\"}"
  echo
  start=$(date -d "$start +92 days" +%Y-%m-%d 2>/dev/null || date -j -v+92d -f %Y-%m-%d "$start" +%Y-%m-%d)
done

Re-POST any range that appears in a failed_ranges (add "chunk_days": 5 if it timed out). Done when every response is "failed_ranges": []. Overlaps and full reruns are harmless — every chunk is a delete-and-recount.

Step 4 — merge #2192 and #2193.

Step 5 — set WORLD_ID_ANALYTICS_ROLLUP_ENABLED=true in developer-portal-deployment. The first tick (≤15 min) catches up everything since the backfill; from then on the cron maintains the trailing ~25h unattended.

Monitoring. One Datadog alert: no "Rolled up World ID analytics" success log for >30 min (a dead cron emits nothing). Chunk failures log as errors with their exact range.

Rollback / repair. The flag is the kill switch — off stops all recurring work instantly while the UI keeps serving the last-rolled numbers. Any suspect date range, any time: re-POST it. Reverting the PR is safe (additive DDL; down.sql exists). The out-of-band indexes remain if the rollout is abandoned; drop them concurrently only if their storage and write overhead are no longer wanted.

Dual v3/v4 action-day rollup tables with cascading FKs, the singleton
watermark state table, the tracked return-shape table, the atomic
five-minute dual-source rollup function under advisory lock (533214,43),
the combined app-daily read function, service-only Hasura metadata, the
protected cron route with a rollout-disable gate, operator scripts for
the out-of-band v3 index and backfill validation, and the fresh-stack
contract harness with its data-layer tests.
The default and --million paths invoked integration.test.ts and
million.test.ts, which land with the read slice; jest exits 1 on a
pattern with no matches, so both entrypoints failed on this branch.
The read slice restores them alongside the files.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@soamdesai-tfh
soamdesai-tfh marked this pull request as ready for review August 3, 2026 00:49

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 9f175224c5

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

A plain CREATE INDEX in the transactional migration blocks nullifier_v4
verification inserts for the build duration while old pods serve during
a rolling deploy. Build it out of band with CONCURRENTLY like the v3
twin: the operator script now creates and validity-gates both indexes,
and the backfill gate refuses to run until both are valid.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ats_daily

The API contract (legacy_actions) and UI scope type (source: "legacy")
already named this concept 'legacy'; only the SQL layer said 'v3'. v3
was never a real protocol generation here - nullifier/action predate
v4 entirely and internally split by engine (on-chain/cloud), not by
any versioning scheme. Align the table/index name and source tags with
the terminology the other two layers already settled on.

Regenerated graphql.schema.json/graphql.ts against the renamed table;
tsc and prettier clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@soamdesai-tfh
soamdesai-tfh force-pushed the feat/world-id-analytics-data branch from 2cd5cd5 to 3c2ff71 Compare August 3, 2026 21:43
The team's concern with the one-shot design: a full-history backfill
runs as one unbounded transaction, so a failure loses all progress and
nothing reports where it died.

rollup_world_id_analytics() now takes max_advance_days, capping how far
one call may advance the watermark in whole UTC days (NULL = unbounded).
The rebuild start stays derived from the watermark, so no sequence of
calls can skip a range. A capped bootstrap from -infinity anchors at the
oldest raw row (-infinity + interval is still -infinity), deletes are
bounded to the chunk's date range, and once the watermark is inside the
25-hour overlap horizon - where the day-aligned target can land at or
below the watermark and would otherwise never advance - the call
finishes at the cutoff instead of stalling.

backfill_world_id_analytics(chunk_days DEFAULT 1) is a new operator-only
procedure that loops the rollup and commits between chunks: an
interrupted backfill keeps its progress, a rerun resumes from the
committed watermark, and the watermark doubles as a live progress
indicator. It aborts loudly on a lock miss and aborts rather than spins
if a chunk ever fails to advance the watermark. The deployment gate's
phase 1 calls it and no longer auto-resets the watermark; only a parity
validation failure requires the documented explicit reset.

The cron route passes max_advance_days: 30 - invisible in steady state
(~25h window), bounds catch-up after an outage to 30 days per tick - and
reports distinct outcomes (advanced with processed_through, lock_missed
as a warning, disabled), so a stalled watermark is observable and lock
misses are no longer silent successes.

Migration 1785459169000 edited in place (unreleased); local stacks that
already applied it must be recreated. Fresh-stack data-slice suite
17/17 including a new chunked-rollup suite (cap math, bootstrap anchor,
empty-database termination, chunk-boundary counts, committed chunks
surviving a failed chunk and resuming); route unit tests 6/6; tsc and
prettier clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A capped rebuild anchors at the oldest raw row, so a rolled row whose
raw rows were later deleted survives a watermark-only reset and fails
parity validation on every retry (reproduced: the gate loops forever).
The documented reset now truncates both rollup tables before resetting
the watermark, and a new gate case pins the trapped scenario and its
recovery. Codex review finding on #2191.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
soamdesai-tfh and others added 2 commits August 5, 2026 17:24
…uild

One SQL function rebuilds an explicit UTC date window (delete + recount,
idempotent); no dates means the trailing ~25h. The watermark singleton,
its bootstrap/clamp logic, and the backfill procedure are gone. The route
gains a dated mode (chunked loop, failed_ranges response, fresh service
client per chunk) that bypasses the rollout flag for operator backfill
and repair; the cron mode stays fail-closed behind it. Advisory lock is
now a blocking wait: Hasura hides Postgres SQLSTATEs from non-admin
roles, so a try-lock miss was indistinguishable from failure. Cron slows
to */15 with matching tolerance. The ops script no longer backfills; it
is the index gates plus a self-contained parity check (catch-up inside
one repeatable-read snapshot, complete days only) whose mismatch sample
names the exact ranges to re-POST — no reset step exists.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…runbook

Window semantics replace the watermark suite (dated ranges, trailing
window scoping, 5-minute clamp, orphan sweep, blocking-lock queueing).
Parity gate tests now prove coverage (never-backfilled history fails)
and repair-by-re-roll. New suites: index build under live writes (25001
in-transaction, 55P03 migration-style blocking, zero lost writes under
CONCURRENTLY), cron wiring + dated mode against the real stack, and an
end-to-end release runbook — dark deploy, three-POST backfill, parity
sign-off, reads per app/environment, a real Hasura-delivered live tick,
late-data heal, deletion sweep, steady-state parity.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Soam's ruling: hasura/operations/world-id-analytics contains exactly one
file — the concurrent index build — because one file is what the platform
team runs. backfill-and-validate.sql is deleted; the parity comparison it
carried now lives in the test suite (readParityMismatches in
canonical-analytics.ts, exercised by rollup-parity.test.ts and the e2e
runbook suite), where it already proved coverage, corruption, and
orphan-sweep behavior. Operator flow is now: index script, dated backfill
POSTs until failed_ranges is empty, flag on.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A concurrent action deletion locks the parent row first and then cascades
into the stats rows the running window just deleted; the rollup's recount
insert then waits on that parent — a lock cycle whose victim is nearly
always the user's delete, because the deletion is structurally the earlier
waiter and PostgreSQL's detector aborts the process that has waited
deadlock_timeout. Reproduced 3/3 on postgres:14 (delete 40P01, rollup
committed); the existing race test missed it because a first-ever rollup
deletes no child rows. Ordered FOR KEY SHARE pre-locks on the recount's
parent actions restore parent-before-child ordering on both sides: the
deletion now parks briefly on the parent and cascades cleanly after the
window commits — verified 3/3, plus a regression test against a re-rolled
window.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This script has a few problems:

  1. You need to store success/failure for a given chunk otherwise they're going to be retried. you can use redis with a long TTL eg. 2 days
  2. You expect from /end data to be passed into the endpoint's body. Why? The backfill should happen everytime the cron job runs without a manual operation. after it succeeds we'll just update the code and revert it to the intended state of triggering for the last date

Comment on lines +64 to +66
retry_interval_seconds: 10
timeout_seconds: 60
tolerance_seconds: 900

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i would increase the timeout to 14m, so the cron job can make enough progress. if we only process rows 1 minute every 15 minutes this could hours/days.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants